Repository navigation
Conversation
Labels on dense scatter plots overlap each other and their markers, and users place every label by hand or with annotations to work around it. With `textposition: 'auto'`, each label takes the first free position next to its point, moves farther out with a leader line when those are taken, and hides when nothing fits. Placement is a deterministic greedy pass over a spatial index, so redraws stay fast and repeatable. The svg scatter family shares the placement through scatter/plot.js. The gl, geo, 3d and quiver traces keep the old value list, because their text rendering does not go through that code. Closes #4674 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
A label next to its point reads as ambiguous when other points sit close by. A point with a neighbor within 1.5 font sizes now skips the positions next to it and starts at the first leader ring. The ring step drops from 1.5 to 1 font size, with 6 rings instead of 4, so the shortest leader is 10px and the reach stays at 6 font sizes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
The leader test ignored any obstacle that covered the leader's start. The start sits at the marker edge, so a neighbor's marker next to the point was ignored too and the leader ran through it. The exemption now applies only to obstacles that cover the point itself. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
A point with a neighbor on one side can still take a label on the other side without doubt about which point owns it. The check now rejects a position next to the point only when another marker sits within 1.5 font sizes of the label box at that position. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
The padded-box check flagged markers off a label's corner as if they sat beside it, and 1.5 font sizes of margin put arrows on labels that read fine without one. The check now measures the distance from the nearest edge or corner of the label box and uses one font size. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
The fixed `textposition` geometry stands the text anchor off the marker, and the anchor is a different part of the text for each position. So *auto* charts, which mix positions, showed uneven gaps: a side label sat 2px from its marker and a corner label 8px. *auto* labels now place the measured text box with one clearance from the marker for every position. Fixed positions keep their geometry. This is self-contained in `textPointBoxOffset` and its two call sites, so it can be removed on its own. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
A corner label kept its clearance from the corner of a square marker, which left circles, the common case, 2px farther than side labels. Circle symbols now get the circle geometry. Every other symbol keeps the square geometry, because a square reaches the farthest of all symbols on the diagonal. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
The mock now holds mixed symbols, per-point font sizes, multi-line labels, bubbles, text-only points, fixed positions mixed with *auto*, points on the plot edges and corners, and a tight cluster. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
After a zoom, a point past the edge of the plot area could still get a label when one candidate box fit inside the axes, which left the label with no visible marker, or a leader that pointed off the plot. Such a point now hides its label. A test zooms into a cluster, then past some of its points, and back out. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
Twelve points on a small circle in clockwise order show whether the labels around a cluster follow the order of their points. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
The mock now holds scenarios where a better placement would show: a clock and a ring for the natural side of a label, a row and a diagonal chain for consistent sides, an order trap where the first point in data order takes the only spot of the second, two clusters for leader crossings, and a blob for how many labels fit. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
Every label tried the same fixed list of positions, so labels around a cluster took whichever spot came first in the list instead of the spot that faces away from the cluster. The candidates are now sorted by the direction that points away from the markers near the point, with the fixed order as the tiebreak, so an isolated point places as before. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
Sixty-seven labeled conditions on log axes, in three styled traces, at a size where the placement must use leader lines and hide labels. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
The placement no longer needs the line count or the fixed-position offset, which only the drawing module uses now. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
5,040 readings at four-minute steps with the value as the label, where only a few hundred labels can fit and each one needs a leader. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
A trace without markers could sit its label on the point, which put the text on top of the line. Only a trace with neither markers nor lines uses that position now. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
The auto pass runs inside the trace plot step, before annotations, shapes, images, and the legend draw. State this in the attribute description so users know to set a fixed position on points that sit under those elements. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
|
This is a great new feature! I have a few comments:
This should fix issue #4674. Could you add that in the description? |
The map layer symbol and the scattermap trace reuse the scatter `textposition` object, so *auto* leaked into their enum through `Lib.extendFlat`. Map-gl has no placement pass and drew such labels at the center without a warning. Filter the value out, as the gl and geo traces already do. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yPmc4gXnKedfLbnBZGhva
`textPointPosition` set `display` on the `<text>` node, which undid the hiding that `convertToTspans` relies on for the tex source. A selected point then showed the raw tex over the rendered math. Hide the `g.textpoint` group instead, so the MathJax render goes with it. A MathJax label renders after the placement pass, so the pass measured the hidden source text and reserved an empty box. Skip a pending label and run the pass again once every render is done, as the axis tick labels do. Measure a rendered label from its math group, and align the math svg to the anchor the placement picks. A leader line was drawn from the `x` and `y` attributes of the text node, which still hold the old values in the middle of a transition. Keep the leader segment on the calcdata point in plot coordinates. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yPmc4gXnKedfLbnBZGhva
The scatter *auto* textposition code held both the placement algorithm and the trace-specific parts: measuring labels, the box geometry of a position, and applying the result to calcdata. Other traces could not reuse it, and a change to the algorithm would have touched the trace code. `Lib.placeLabels` in `geometry2d.js` now takes marker boxes, fixed boxes, and labels that supply a `rect(position, gap)` callback, and returns a position, gap, and leader per label. The scatter code only gathers the inputs and applies the results. The engine places labels by priority through a sort helper, so that rule can change in one place. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yPmc4gXnKedfLbnBZGhva
A dense trace can show only some of its labels, and the placement order decided which ones. `textpriority`, per point or per trace, lets the figure say which labels matter: they take their position first, so they stay next to their point and are hidden last. It is coerced only when `textposition` has *auto*, and only the SVG scatter traces carry it. The time-series mock marks the extrema of each four-hour window with priority 1, so the peaks and troughs keep their value labels. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yPmc4gXnKedfLbnBZGhva
Every redraw placed the labels from scratch, so a pan that moved a neighbour by a few px could flip a label to the other side of its point, and one such move could cascade through the labels around it. A label now names the position it had before, and the engine tries that one first in every ring. The label still moves when its old spot is taken, and still comes closer when a nearer ring frees up. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yPmc4gXnKedfLbnBZGhva
Trying the old position first still let a label step through the rings on every pan, so its leader line came and went. The label now goes back next to its point at its old position when it can, else it keeps its old position and gap, and only then it searches again. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yPmc4gXnKedfLbnBZGhva
| if (sx && sy) { | ||
| var isCircle = r && drawing.symbolNumber(markerSymbol) % 100 === 0; | ||
| reach = isCircle ? (r + clearance) / Math.SQRT2 : r + clearance / Math.SQRT2; | ||
| } |
There was a problem hiding this comment.
Quick explanation of this part: In my reference scatter plot, I noticed that the text was closer to the squares than the circles, because the circles' corners are folded in. In dense scatter plots with text, I find myself comparing the text to different points to try to understand which text is associated with which point, and this discrepancy of distance was throwing me off.
Circles are the default case, so that's handled. And then squares are the largest symbol from the corner, so that's the default otherwise for the rest of the symbols.
I recognize this is getting kind of hardcore, and would also be OK removing this logic as well.
|
Hi @chriddyp
|
| const POSITIONS = [ | ||
| 'top center', | ||
| 'bottom center', | ||
| 'middle right', | ||
| 'middle left', | ||
| 'top right', | ||
| 'top left', | ||
| 'bottom right', | ||
| 'bottom left' | ||
| ]; |
There was a problem hiding this comment.
This list should also include the middle center as the first member in the case of no marker e.g. when mode: 'text' so let's get that from a function with the mode as an input argument.
Right now e.g. on the text_chart_autoposition_real mock if you call
Plotly.restyle(gd, 'mode', 'text');the text positions are not center.
There was a problem hiding this comment.
@camdecoster Is this case handled now? We also need a test for it.
There was a problem hiding this comment.
It looks like Chris already handled that. I just added a test.
Good call, agreed!
Ah yeah that's a great point as well. Agreed 👍🏼 Many thanks for taking a look @archmoj ! |
chriddyp
left a comment
There was a problem hiding this comment.
I reviewed this against a local build in headless Chromium. Each inline comment has a repro, the numbers I measured on this branch, and a proposed fix that I applied locally and measured again. With all the fixes applied together, the 3 new text_chart_autoposition* mocks place every label exactly as before, so no baseline should move. I did not run the jasmine or image test suites.
Ranked by impact:
text_defaults.js: crash on scattergl, scatter3d, scattergeo and scatterpolargl when an arraytextpositionholds auto.auto_text_position.jsL30: zorder groups, overlaid axes and carpets are placed in separate passes, so their labels overlap each other.drawing/index.jsL1349: MathJax label boxes are cached without the font size.auto_text_position.jsL104: the obstacle box of a fixed corner label is offset from the drawn text (noticeable with large markers).drawing/index.jsL1414: leader lines detach after a selection withselected.marker.size.helpers.js:cleanDatastopped cleaning auto on traces that don't support it.
Checked and fine, so no inline comment:
- Leader lines during scroll zoom stay on their markers, because
setTextPointsScalescales around the point. textPointPositionruns about 3 times per label per redraw. On the timeseries mock, all those calls together take about 10% of a relayout, so removing one pass isn't worth the code.
Generated by Claude Code
| if (textposition === 'auto' || (Lib.isArrayOrTypedArray(textposition) && textposition.indexOf('auto') !== -1)) { | ||
| coerce('textrank'); |
There was a problem hiding this comment.
Crash: textrank is coerced on traces that don't have it
This file is shared by scattergl, scatterpolargl, scatter3d, scattergeo and scattermap. Those traces strip auto from textposition.values (scatterpolargl also strips textrank), but items of an arrayOk array aren't checked against values. So an array that holds auto reaches this line, coerce('textrank') finds no attribute, and it throws.
Repro (throws on this branch, plots fine on main):
Plotly.newPlot(gd, [{
type: 'scattergl', mode: 'text',
x: [1, 2], y: [1, 2], text: ['a', 'b'],
textposition: ['auto', 'top center']
}]);
// TypeError: Cannot read properties of undefined (reading 'dflt')Same error for scatter3d, scattergeo and scatterpolargl, and for Plotly.restyle(gd, 'type', 'scattergl') on a scatter trace with textposition: ['auto', 'auto'].
Proposed fix: add a flag next to noSelect and pass it from the 4 traces that already remove auto from their attributes. scattermap is safe as is, because its textposition has arrayOk: false.
/*
* opts: object of flags to control features not all text users support
* noSelect: caller does not support selected/unselected attribute containers
* noAutoPosition: caller has no *auto* textposition, so no textrank
*/
module.exports = function(traceIn, traceOut, layout, coerce, opts) {
opts = opts || {};
var textposition = coerce('textposition');
// An array can hold *auto* even where it is not a valid value, because array items are not validated
if (
!opts.noAutoPosition &&
(textposition === 'auto' || (Lib.isArrayOrTypedArray(textposition) && textposition.indexOf('auto') !== -1))
) {
coerce('textrank');
}// scattergl/defaults.js; the same flag in scatter3d, scatterpolargl and scattergeo defaults
handleTextDefaults(traceIn, traceOut, layout, coerce, {
noAutoPosition: true,
noFontShadow: true,
noFontLineposition: true,
noFontTextcase: true
});I applied this locally, and the repro plots without error for all 4 trace types and for the restyle. A jasmine case per trace type with an array that holds auto would guard it.
Side note: the "auto, or an array that holds auto" check now lives in 3 places (here, hasAutoTextPosition in auto_text_position.js, and plot.js L531). One exported helper would keep them in sync.
Generated by Claude Code
| module.exports = function autoTextPosition(gd, plotinfo, traceGroups) { | ||
| // The slider shares the calcdata with the main plot, so a pass here would replace the main plot's results | ||
| if (plotinfo.isRangePlot) return; |
There was a problem hiding this comment.
Labels in different zorder groups, on overlaid axes, or on carpets don't avoid each other
This runs once per call of the scatter plot method. On a cartesian subplot, plotOne calls that method once per zorder group (scatterlayer, scatterlayer2, ...). Every overlaid subplot (yaxis2.overlaying: 'y') and every carpet gets its own call too. Each pass sees only part of the traces in the plot area, so labels from different passes overlap freely. Dual-y-axis charts are common, so I'd rank this one high.
Repro: 2 traces with 12 points each, B shifted slightly from A, both textposition: 'auto' and marker.size: 12. I counted overlapping screen boxes between one trace's labels and the other trace's markers and labels:
| setup | label on other trace's marker | label on label |
|---|---|---|
| same zorder | 0 | 0 |
zorder: 0 and zorder: 1 |
16 | 8 |
B on yaxis: 'y2', overlaying: 'y' |
12 | 12 |
| scatter + scattercarpet on the same axes | 0 | 6 |
const x = [...Array(12).keys()], y = x.map(i => i % 3);
Plotly.newPlot(gd, [
{ x, y, mode: 'markers+text', text: x.map(i => 'A' + i), textposition: 'auto', marker: { size: 12 }, zorder: 0 },
{ x: x.map(i => i + 0.15), y: y.map(v => v + 0.12), mode: 'markers+text', text: x.map(i => 'B' + i),
textposition: 'auto', marker: { size: 12 }, zorder: 1 }
], { width: 600, height: 400, showlegend: false });Proposed fix: let every pass cover all scatter traces in the same cartesian plot area, and use each trace's own axes for the pixel positions. The last pass sees everything, so the end state is right, and prefer keeps the earlier passes from shuffling labels. Polar, ternary and smith keep today's behavior, because their stand-in axes aren't figure axes.
const Axes = require('../../plots/cartesian/axes');
module.exports = function autoTextPosition(gd, plotinfo, traceGroups) {
if (plotinfo.isRangePlot) return;
// Each `plot` call draws one zorder group, one overlaid subplot or one carpet,
// so place the labels of every scatter trace that shares the plot area
traceGroups = plotAreaTraceGroups(gd, plotinfo, traceGroups);
// ...
traceGroups.each(function (cd) {
// ...
const xa = traceAxis(gd, trace.xaxis, plotinfo.xaxis);
const ya = traceAxis(gd, trace.yaxis, plotinfo.yaxis);
// ...
// a cartesian axis of the figure, or undefined for the stand-in axes of polar, ternary and smith subplots
function cartesianAxis(gd, ax) {
return ax && ax._id && Axes.getFromId(gd, ax._id) === ax ? ax : undefined;
}
function mainAxis(gd, ax) {
return ax.overlaying ? Axes.getFromId(gd, ax.overlaying) : ax;
}
function traceAxis(gd, id, dflt) {
return (cartesianAxis(gd, dflt) && id && Axes.getFromId(gd, id)) || dflt;
}
// the `g.trace` groups of the scatter traces drawn in the same cartesian plot area
function plotAreaTraceGroups(gd, plotinfo, traceGroups) {
const xa = cartesianAxis(gd, plotinfo.xaxis);
const ya = cartesianAxis(gd, plotinfo.yaxis);
if (!xa || !ya) return traceGroups;
const mx = mainAxis(gd, xa);
const my = mainAxis(gd, ya);
return gd._fullLayout._cartesianlayer.selectAll('g.trace.scatter').filter((cd) => {
const trace = cd && cd[0] && cd[0].trace;
if (!trace || !trace.xaxis || !trace.yaxis) return false;
return (
mainAxis(gd, Axes.getFromId(gd, trace.xaxis)) === mx &&
mainAxis(gd, Axes.getFromId(gd, trace.yaxis)) === my
);
});
}With this applied locally, all 4 rows go to 0 / 0. Side-by-side subplots stay independent, and the range slider isn't touched, since it doesn't draw under _cartesianlayer. The 3 new text_chart_autoposition* mocks and auto labels on polar, ternary and smith place every label exactly as before. Cost: one more pass for each extra zorder group or overlay that has scatter traces.
One gap remains: plotinfo.axisLayers still comes from the calling subplot only, so inside tick labels of an overlaid axis aren't obstacles. Merging axisLayers from the main subplot and its overlays would close it.
Generated by Claude Code
| const math = d3.select(node.parentNode).select('g.text-math-group'); | ||
| let bb; | ||
| if (math.size()) { | ||
| bb = drawing.bBox(math.node()); |
There was a problem hiding this comment.
MathJax label boxes are cached without the font size
drawing.bBox(math.node()) falls back to the default nodeHash. On the g.text-math-group node that is data-unformatted + 'Y' + null + null, so the key has no font. The first size measured for a TeX string is reused for every other label with the same string.
Repro (MathJax v3 loaded):
Plotly.newPlot(gd, [
{ x: [0.5], y: [0.5], mode: 'markers+text', text: ['$\\alpha$'], textposition: 'auto', textfont: { size: 10 } },
{ x: [1, 1.3, 1.6, 1.9, 2.2, 2.5], y: [2, 2.1, 2, 2.1, 2, 2.1], mode: 'markers+text',
text: Array(6).fill('$\\alpha$'), textposition: 'auto', textfont: { size: 40 } }
], { width: 500, height: 400, xaxis: { range: [0, 3] }, yaxis: { range: [0, 3] }, showlegend: false });The placement measures the 40px labels as 5.9×4.7 px, the size of the 10px one. The real box is 23.6×18.8 px. On screen, one α gets cut off at the right edge of the plot, one sits on a marker, and the leaders stop short of the glyphs. Per-point textfont.size arrays hit the same thing.
Proposed fix: key the render by its source <text> node, whose style has the font:
if (math.size()) {
// The default key of the render has no font, so key it by its source `<text>` node, which has the font
const hash = textPointHash(node);
bb = drawing.bBox(math.node(), false, hash && hash + '~math');With this applied locally, the placement measures 23.6×18.8 px, every label clears the markers, and the one with no free spot is hidden.
Generated by Claude Code
| if (pos === 'auto') { | ||
| labels.push(label); | ||
| } else { | ||
| const box = label.rect(pos, 0); | ||
| box.owner = d; | ||
| fixed.push(box); | ||
| } |
There was a problem hiding this comment.
The obstacle box of a fixed label isn't where the label is drawn
label.rect(pos, 0) uses textPointBoxOffset, the auto geometry. A fixed label is drawn by textPointOffset, though, which puts a corner label r / 0.8 + 1 out on both axes, while textPointBoxOffset puts it (r / 0.8 + 1) / √2 out for a circle. For corner positions the obstacle therefore sits closer to the marker than the real text. Side positions match.
Obstacle box minus drawn <text> box at font size 14 (x, y offset in px):
| marker.size | top right | bottom left | top center, middle right |
|---|---|---|---|
| 6 | -1.4, +1.9 | +1.4, +1.1 | ≤ 1.5 |
| 12 | -2.5, +3.0 | +2.5, 0 | ≤ 1.5 |
| 40 | -7.6, +8.1 | +7.6, -5.1 | ≤ 1.5 |
At the default marker size that's inside PAD. With larger markers an auto label can land on a fixed one:
Plotly.newPlot(gd, [
{ x: [2], y: [5], mode: 'markers+text', text: ['Fixed label'], textposition: 'top right', marker: { size: 40 } },
{ x: [2.6], y: [5.6], mode: 'markers+text', text: ['auto'], textposition: 'auto' }
], { width: 500, height: 400, margin: { l: 0, r: 0, t: 0, b: 0 },
xaxis: { range: [0, 10] }, yaxis: { range: [0, 10] }, showlegend: false });'auto' takes top right and overlaps 'Fixed label' by about 24×4 px. Sweeping the auto point over a 21×25 grid next to the fixed label gave 27 positions with an overlap.
Proposed fix: build the box from the geometry that drew the label. This needs textPointOffset on drawing (like textPointFontSize), plus the box math pulled out of labelRect so both paths share it:
const bb = Drawing.textPointBBox(tx);
const label = {
// ...
rect: labelRect(d, trace, x, y, bb)
};
if (pos === 'auto') {
labels.push(label);
} else {
// A fixed label is drawn with the fixed-position geometry, so block the box it really covers
const offset = Drawing.textPointOffset(pos, label.fontSize, d.mrc, svgTextUtils.lineCount(tx));
fixed.push({ ...boxAt(x, y, offset, bb), owner: d });
}
// ...
function labelRect(d, trace, x, y, bb) {
const symbol = d.mx || (trace.marker || {}).symbol;
return (pos, gap) => {
const offset = Drawing.textPointBoxOffset(pos, d.mrc, symbol, bb, gap);
const seg = offset.leader;
return { ...boxAt(x, y, offset, bb), leader: seg ? [x + seg[0], y + seg[1], x + seg[2], y + seg[3]] : null };
};
}
// the box of a label drawn with `offset` from its point (x, y), in the plot frame
function boxAt(x, y, offset, bb) {
let x0 = x + offset.dx;
if (offset.anchor === 'end') x0 -= bb.width;
else if (offset.anchor === 'middle') x0 -= bb.width / 2;
const y0 = y + offset.dy + bb.top;
return { x0, y0, x1: x0 + bb.width, y1: y0 + bb.height };
}With this applied locally, the obstacle matches the drawn box to 0 px in every cell of the table, the grid sweep finds no overlaps, and the 3 new mocks don't change.
Generated by Claude Code
| const hasLeader = first && first.nodeName === 'path' && first.classList.contains('textleader'); | ||
| if (!d._tpAutoLeader && !hasLeader) return; | ||
|
|
||
| var leader = group.selectAll('path.textleader').data(d._tpAutoLeader ? [d._tpAutoLeader] : []); |
There was a problem hiding this comment.
Leader lines detach from their labels after a selection
selectedTextStyle (L1505) calls this with d.mrc2 || d.mrc. For an auto label, textPointBoxOffset then moves the text out to the selected marker radius. But d._tpAutoLeader was fixed in the plot frame for d.mrc, so the leader stays where it was while the text moves.
Repro: 40 random points, marker.size: 8, selected.marker.size: 20, textposition: 'auto', dragmode: 'select', then box-select everything. 7 labels have leaders:
| leader end to label | leader start to marker edge | |
|---|---|---|
| before select | ≤ 2 px | ≤ 1 px |
| after select, this branch | up to 9.5 px | up to 5 px, inside the bigger marker |
| after select, fix below | ≤ 2 px | ≤ 1 px |
Fixed labels already move with mrc2, so moving the auto label is consistent. Only the leader has to follow. Shift the stored leader by the change in the two offsets:
var offset;
var leaderSeg = d._tpAutoLeader;
if (isAuto && textPosition && d._tpAuto !== undefined) {
var symbol = d.mx || (trace.marker || {}).symbol;
var bb = drawing.textPointBBox(s);
offset = drawing.textPointBoxOffset(textPosition, markerRadius, symbol, bb, d._tpAutoGap);
// The placement set the leader for `d.mrc`, so move its ends with a selected or unselected marker size
if (leaderSeg && markerRadius !== d.mrc) {
const placed = drawing.textPointBoxOffset(textPosition, d.mrc, symbol, bb, d._tpAutoGap).leader;
leaderSeg = leaderSeg.map((v, k) => v - placed[k] + offset.leader[k]);
}
// ...
}
// ...
if (!leaderSeg && !hasLeader) return;
var leader = group.selectAll('path.textleader').data(leaderSeg ? [leaderSeg] : []);Known limit: the placement doesn't run again on select, so a label pushed out by a bigger selected marker can still overlap a neighbor (2 label pairs in the repro). Fixed labels act the same way today. Re-running the placement on every select mousemove would cost too much, so I'd leave that alone.
Generated by Claude Code
| // textposition - support partial attributes (ie just 'top') | ||
| // and incorrect use of middle / center etc. | ||
| function cleanTextPosition(textposition) { | ||
| if (textposition === 'auto') return textposition; |
There was a problem hiding this comment.
cleanData now keeps auto on every non-bar, non-pie trace
On main, cleanTextPosition('auto') returned middle center for every trace that gets here. Now auto passes through for all of them, including traces that don't accept it. Differences from main:
| trace | input | main | this branch |
|---|---|---|---|
| scatter3d | 'auto' |
middle center | top center (falls back to the scatter3d default) |
| scatter3d, scattergl, scattergeo | ['auto', 'auto'] |
['middle center', 'middle center'] |
throws (see the text_defaults.js comment) |
The scatter3d change is small, but it changes how existing figures render. Proposed fix: keep auto only when the trace type accepts it.
if (!traceIs(trace, 'pie-like') && !traceIs(trace, 'bar-like')) {
// Keep *auto* only where it is valid, and clean it to *middle center* as before everywhere else
var tpValues = ((Registry.getModule(trace.type || 'scatter') || {}).attributes || {}).textposition;
var keepAuto = !!tpValues && (tpValues.values || []).indexOf('auto') !== -1;
if (Array.isArray(trace.textposition)) {
for (i = 0; i < trace.textposition.length; i++) {
trace.textposition[i] = cleanTextPosition(trace.textposition[i], keepAuto);
}
} else if (trace.textposition) {
trace.textposition = cleanTextPosition(trace.textposition, keepAuto);
}
}
// ...
function cleanTextPosition(textposition, keepAuto) {
if (textposition === 'auto' && keepAuto) return textposition;With this and the text_defaults.js fix applied locally, scatter3d, scattergl and scattergeo give the same textposition as main for both inputs, and scatter, scatterpolar, scatterternary, scattersmith and scattercarpet keep auto.
Generated by Claude Code






Fixed #4674
Overview written in my own words
I was making the graph below and running into the classic text overlap issues. This PR adds
textposition: 'auto'to avoid text overlap.Before:

After:

Intuitively, we try to find text position that:
For avoidance, it will avoid:
It won't:
This works very well for the example above.
It doesn't work as well for dense time series. In these cases, I can imagine that we should update the algorithm to focus on showing text for local minima and maxima first, rather than going in the order of the traces. I think we could do so in the future.
In that example, performance is about 1s slower than not displaying the text.
The positioning is also done on zooming and panning. Here's a video.
textposition-auto-zoom-pan.webm
Below is written with the use of AI
This PR adds support for
textposition: 'auto'on scatter traces, enabling automatic placement of text labels without overlaps. When set to'auto', labels are positioned around their points in a priority order, with leader lines drawn when labels are moved away from their markers due to space constraints.src/traces/scatter/auto_text_position.js: Implements the auto-positioning algorithm using a spatial index to detect overlaps and place labels efficiently. The algorithm:textpositionarraysAPI Updates
src/traces/scatter/attributes.jsto add'auto'as a validtextpositionvaluesrc/traces/scatter3d/attributes.js,src/traces/quiver/attributes.js,src/traces/scattergeo/attributes.js, andsrc/traces/scattergl/attributes.jsto exclude'auto'from non-2D scatter variants (with comments explaining why)Drawing & Positioning
src/components/drawing/index.js:textPointBoxOffset()to compute label geometry from measured bounding boxestextPointPosition()to support both fixed and auto-positioned labelsgetTextPosition()helper to resolve'auto'to computed positionsIntegration
src/traces/scatter/plot.jsto call the auto-positioning algorithm after rendering all traces on a subplotsrc/plot_api/helpers.jsto preserve'auto'incleanTextPosition()Tests & Examples
test/jasmine/tests/scatter_test.jscovering:test/image/mocks/text_chart_autoposition.jsonwith diverse label scenariostest/image/mocks/text_chart_autoposition_real.json(NIH funding data)Technical Details
Setup, once per subplot, before any label moves:
Index every marker from every visible scatter trace on the subplot as an obstacle, plus every label that has a fixed (non-auto) position. Collect the auto labels in trace draw order, then data order within a trace.
Then, for each auto label in that order:
Bail if the point is off-plot. If the point's pixel coords fall outside the plot area, hide the label and stop. No candidates are tried.
Build the repulsion vector. Sum
(dx/d², dy/d²)over every other point's marker withinmrc + 3 × fontSizeof this point, wheredis the distance and the vector points away from each marker. Nearer markers contribute more. Only markers count, not other labels.Score and sort all 8 positions once. Each position is a unit direction (
top right=(1,-1)/√2, and so on). Score is the dot product with the repulsion vector. Sort descending, ties broken by the default preference order. If the repulsion vector came out zero, skip the sort and use the default order as-is, which starts attop center. This ordering is computed once and reused at every ring.Ring 0: try each position touching the point, in that order. A candidate wins if its box stays inside the plot area, overlaps no marker, no already-placed label, and no already-placed leader line, and no foreign marker sits within one font size of the box. That last check is the one people miss: a spot can be geometrically free and still get rejected as too ambiguous to read.
No winner at ring 0, step out one font size and run the same list again. Now the label gets a leader line back to the marker edge, and the leader itself has to be clear of obstacles too. Keep stepping out one font size at a time, up to ring 6, re-running the full ordered list at each distance.
Nothing free at any ring: hide the label (
display: none).On a win, insert the box and its leader into the index, so this label becomes an obstacle for every label placed after it.
It's greedy and single-pass. No backtracking, no relaxation, no global optimization.
A point early in the data can permanently push a later point's label into hiding, and reordering your traces changes the output.
Labels are processed in trace and data order, so earlier points win contested positions.
Test Plan
Existing test suite passes. New tests verify: